All files / src/app/collections/[id]/results/[resultNumber] page.tsx

97.14% Statements 34/35
87.5% Branches 28/32
100% Functions 10/10
96.96% Lines 32/33

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129                                                25x 25x 25x       25x 25x   24x   24x       25x 3x       21x 1x     20x           57x 32x         20x   51x 2x           20x 20x 40x 51x 51x 20x 20x 20x 51x   20x 51x 51x     20x 25x   25x                                                                                  
import { getSession } from "@/lib/auth";
import { redirect } from "next/navigation";
import { getDb } from "@/lib/db";
import { requireCollectionAccess } from "@/lib/collection-access";
import { ItemController } from "@/server/api/items";
import { ResultItemController } from "@/server/api/result-items";
import { ResultController } from "@/server/api/results";
import { ShareController } from "@/server/api/shares";
import { PhotoController } from "@/server/api/photos";
import { getPhotoAssetUrlForPhoto, PHOTO_WIDTH } from "@/lib/photos";
import { AppLayout } from "@/components/layout";
import { trail } from "@/components/layout/breadcrumb-trails";
import { Badge, EmptyState } from "@/components/ui";
import { RankedResults } from "@/features/results/RankedResults";
import { MakeRevisionButton } from "@/features/results/MakeRevisionButton";
import { SharePanel } from "@/features/results/SharePanel";
import { partitionRanked } from "@/lib/results-buckets";
import type { UsageAnswer } from "@/lib/derive-usage";
 
interface ResultDetailPageProps {
  params: Promise<{ id: string; resultNumber: string }>;
}
 
export default async function ResultDetailPage({ params }: ResultDetailPageProps) {
  const { id, resultNumber: resultNumberParam } = await params;
  const session = await getSession();
  Iif (!session?.user) {
    redirect("/login");
  }
 
  const db = getDb();
  const collection = await requireCollectionAccess(db, id, session.user.id);
 
  const resultNumber = Number(resultNumberParam);
  const result =
    resultNumber > 0 && Number.isInteger(resultNumber)
      ? await ResultController.fromDb(db).getByCollectionAndNumber(collection.id, resultNumber)
      : null;
 
  if (!result || result.collectionId !== collection.id) {
    redirect(`/collections/${collection.slug ?? collection.id}/results`);
  }
 
  // Drafts are adjusted on the live Ranking now (spec §4.3); a saved one is a keepsake.
  if (result.status === "draft") {
    redirect(`/collections/${collection.slug ?? collection.id}/results`);
  }
 
  const [resultItems, items, shares] = await Promise.all([
    ResultItemController.fromDb(db).listResultItems(result.id),
    ItemController.fromDb(db).listItems(collection.id),
    ShareController.fromDb(db).listShares(result.id),
  ]);
 
  const itemById = new Map(items.map((item) => [item.id, item]));
  const sortedResultItems = [...resultItems].sort((a, b) => (a.manualRank ?? a.computedRank) - (b.manualRank ?? b.computedRank));
 
  // Epic S: the SNAPSHOT usage (frozen at materialization) drives the same quiet
  // chip the live Standing renders. A legacy result whose rows are all NULL
  // yields an empty map → no chip anywhere (backward compat).
  const usageByItemId = new Map<string, UsageAnswer>(
    resultItems
      .filter((ri) => ri.usage != null)
      .map((ri) => [ri.itemId, ri.usage as UsageAnswer]),
  );
 
  // Affirmation, not subtraction (TONE #1): the loved tiers lead and the lowest
  // tier is partitioned into its own forward-framed Onward section — never tallied,
  // never the headline. effectiveBucket(ri) is the single source of truth for which.
  const { loved: lovedRows, onward: onwardRows } = partitionRanked(sortedResultItems);
  const join = <T extends { itemId: string }>(rows: T[]) =>
    rows
      .map((ri) => ({ ri, item: itemById.get(ri.itemId) }))
      .filter((r): r is { ri: typeof r.ri; item: NonNullable<typeof r.item> } => Boolean(r.item));
  const loved = join(lovedRows);
  const onward = join(onwardRows);
  const covers = await PhotoController.fromDb(db).findCoverPhotosByItemIds(
    [...loved, ...onward].map((r) => r.item.id),
  );
  const coverUrl = (itemId: string) => {
    const c = covers.get(itemId);
    return c ? getPhotoAssetUrlForPhoto(c, collection.id, { w: PHOTO_WIDTH.card }) : undefined;
  };
 
  const collectionSegment = collection.slug ?? collection.id;
  const isCurator = result.curatorId === session.user.id;
 
  return (
    <AppLayout breadcrumbs={trail.result(collection, result)}>
      <div className="space-y-8">
        <div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
          <div>
            <h1 className="text-2xl text-ink sm:text-3xl">{result.name}</h1>
            <div className="mt-3">
              {/* Person-side status, not the machine word (TONE #1). Drafts
                  redirected above, so only a saved keepsake reaches here. */}
              <Badge variant="finalized">Saved</Badge>
            </div>
          </div>
        </div>
 
        {loved.length === 0 && onward.length === 0 ? (
          <EmptyState heading="No ranked items yet" message="A little more sorting and your ranking will appear here." />
        ) : (
          <div className="space-y-6">
            {isCurator && (
              <MakeRevisionButton resultId={result.id} rankingHref={`/collections/${collectionSegment}/results`} />
            )}
            <RankedResults
              loved={loved}
              onward={onward}
              collectionId={collectionSegment}
              coverUrl={coverUrl}
              usageByItemId={usageByItemId}
            />
          </div>
        )}
 
        {isCurator && (
          <SharePanel
            resultId={result.id}
            initialShares={shares}
          />
        )}
      </div>
    </AppLayout>
  );
}